#  Copyright 2007, Benjamin Feng
#
#  Redistribution and use in source and binary forms, with or without
#  modification, are permitted provided that the following conditions are met:
#
#   1. Redistributions of source code must retain the above copyright notice,
#      this list of conditions and the following disclaimer.
#   2. Redistributions in binary form must reproduce the above copyright
#      notice, this list of conditions and the following disclaimer in the
#      documentation and/or other materials provided with the distribution.
#   3. The name of the author may not be used to endorse or promote products
#      derived from this software without specific prior written permission.
#
#  THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR IMPLIED
#  WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
#  MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
#  EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
#  SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
#  PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
#  OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
#  WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
#  OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
#  ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


import functools
import os


class TargetNotFoundException(Exception):
    pass


def make_key(d):
    return tuple(d.items())


class Task(object):
    """Tasks will run only upon first invocation or an explicit force."""

    def __init__(self, func, target=None, alias=None, depends=None):
        """
        Only func and target should be called with variable arguments. The rest
        should be keyword arguments for clarity.
        """
        super(Task, self).__init__()

        self.func = func
        self.target = target or func.__name__.replace('__', '.')
        self.alias = alias or []
        self.descr = func.__doc__
        self.depends = depends or []
        self._cache = {}

    @classmethod
    def decorator(cls, arg=None, **kwargs):
        """
        Simple:
            >>> @task
            ... def func():
            ...     pass
            ...
        Complex:
            >>> @task('new_func', depends=['func'])
            ... def old_func():
            ...     pass
        """
        if arg and callable(arg):
            return cls(arg, **kwargs)
        else:
            return functools.partial(cls, target=arg, **kwargs)

    def __repr__(self):
        return "<%s %s>" % (type(self).__name__, self.target)

    def __call__(self, *args, **kwargs):
        return self.func(*args, **kwargs)

    def run(self, tasklist, **kwargs):
        key = make_key(kwargs)
        if self.needs_run(**kwargs):
            self.run_depends(tasklist)
            self._cache[key] = self.func(**kwargs)

        return self._cache[key]

    def needs_run(self, **kwargs):
        return make_key(kwargs) not in self._cache

    def run_depends(self, tasklist):
        for depend in self.depends:
            tasklist.run_task(depend)


class Filetask(Task):
    """
    Filetasks will run only when the target does not exist or the timestamp is
    no earlier than the dependents.
    """

    def __init__(self, *args, **kwargs):
        """
        Filetasks should not return anything as it is unpredictable whether
        the task ran due to timestamp processing.
        """
        super(Filetask, self).__init__(*args, **kwargs)
        self.value = None

    def needs_run(self, **kwargs):
        if not super(Filetask, self).needs_run(**kwargs):
            return False
        else:
            try:
                target_changed = os.path.getmtime(self.target)
            except OSError:
                return True
            else:
                for depend_file_name in self.depends:
                    try:
                        depend_change = os.path.getmtime(depend_file_name)
                    except OSError:
                        raise TargetNotFoundException(depend_file_name)
                    else:
                        if depend_change > target_changed:
                            return True
                self._cache[make_key(kwargs)] = True
                return False

    def run_depends(self, tasklist):
        for depend in self.depends:
            # Files do not need to be declared as tasks.
            if depend in tasklist:
                tasklist.run_task(depend)


class Tasklist(object):
    def __init__(self):
        self._tasks = {}

    @classmethod
    def from_module(cls, module):
        tasklist = cls()
        for obj in vars(module).values():
            if hasattr(obj, 'target'):
                tasklist.add(obj)
        return tasklist

    def __contains__(self, key):
        return key in self._tasks

    def add(self, task):
        task.tasklist = self
        self._tasks[task.target] = task
        for alias in task.alias:
            self._tasks[alias] = task

    def run_options(self, options):
        available = {'l': self.list}
        for option in options:
            available[option]()

    def run_tasks(self, targets=['default'], kwargs={}):
        for target in targets:
            self.run_task(target, kwargs)

    def run_task(self, target, kwargs={}):
        try:
            task = self._tasks[target]
        except KeyError:
            raise TargetNotFoundException(target)
        else:
            return task.run(self, **kwargs)

    def list(self):
        for task in self._tasks:
            print task
